<!DOCTYPE html>
<html>
<head id="joplin-container-root-head">
	<meta charset="UTF-8">
	<title>Note viewer</title>

	<style>
		body {
			overflow: hidden;
		}

		#joplin-container-content {
			/* Needs this in case the content contains elements with absolute positioning */
			/* Without this they would just stay at a fixed position when scrolling */
			position: relative;
			overflow-y: auto;
			padding-left: 10px;
			padding-right: 10px;

			/* Note: the height is set via updateBodyHeight(). Setting it here to 100% */
			/* won't work with some pages due to the position: relative */
		}

		#rendered-md {
			/* This is used to enable the scroll-past end behaviour. The same height should */
			/* be applied to the editor. */
			padding-bottom: 400px;
		}

		.mark-selected {
			background: #CF3F00;
			color: white;
		}

		ul ul, ul ol, ol ul, ol ol   {
			margin-bottom: 0px;			
		}
	</style>
</head>

<body id="joplin-container-body">
	<div id="joplin-container-pluginAssetsContainer"></div>
	<div id="joplin-container-markScriptContainer"></div>
	<div id="joplin-container-content" ondragstart="return false;" ondrop="return false;"></div>
	<script src="./lib.js"></script>
	<script src="./scrollmap.js"></script>

	<script>
	// This is function used internally to send message from the webview to
	// the host.
	const ipcProxySendToHost = (methodName, arg) => {
		window.parent.postMessage({ target: 'main', name: methodName, args: [ arg ] }, '*');
	}

	const webviewApiPromises_ = {};

	// This function is reserved for plugin, currently only to allow
	// executing a command, but more features could be added to the object
	// later on.
	const webviewApi = {
		postMessage: function(contentScriptId, message) {
			const messageId = 'noteViewer_' + Date.now() + Math.random();

			const promise = new Promise((resolve, reject) => {
				webviewApiPromises_[messageId] = { resolve, reject };
			});

			ipcProxySendToHost('postMessageService.message', {
				contentScriptId: contentScriptId,
				viewId: '',
				from: 'contentScript',
				to: 'plugin',
				id: messageId,
				content: message,
			});

			return promise;
		},
	}

	let pluginAssetsAdded_ = {};

	try {
		const contentElement = document.getElementById('joplin-container-content');

		const ipc = {};

		window.addEventListener('message', webviewLib.logEnabledEventHandler(event => {
			// Here we only deal with messages that are sent from the main Electron process to the webview.
			if (!event.data || event.data.target !== 'webview') return;

			const callName = event.data.name;
			const callData = event.data.data;

			if (!ipc[callName]) {
				console.warn('Missing IPC function:', event.data);
			} else {
				ipc[callName](callData);
			}
		}));
		
		// Note: the scroll position source of truth is "percentScroll_". This is easier to manage than scrollTop because
		// the scrollTop value depends on the images being loaded or not. For example, if the scrollTop is saved while
		// images are being displayed then restored while images are being reloaded, the new scrollTop might be changed
		// so that it is not greater than contentHeight. On the other hand, with percentScroll it is possible to restore
		// it at any time knowing that it's not going to be changed because the content height has changed.
		let percentScroll_ = 0;

		let ignoreNextScrollTime_ = Date.now();
		let ignoreNextScrollEventCount_ = 0;

		// ignoreNextScrollEvent() provides a way to skip scroll events for a certain duration.
		// In general, it should be called whenever the scroll value is set explicitly (programmatically)
		// so as to differentiate scroll events generated by the user (when scrolling the view) and those
		// generated by the application.
		function ignoreNextScrollEvent() {
			const now = Date.now();
			if (now >= ignoreNextScrollTime_) ignoreNextScrollEventCount_ = 0;
			if (ignoreNextScrollEventCount_ < 10) { // for safety
				ignoreNextScrollTime_ = now + 1000;
				ignoreNextScrollEventCount_ += 1;
			}
		};

		// Tests the next scroll event should be ignored and then decrements the count.
		function isNextScrollEventIgnored() {
			if (ignoreNextScrollEventCount_) {
				if (Date.now() < ignoreNextScrollTime_) {
					ignoreNextScrollEventCount_ -= 1;
					return true;
				}
				ignoreNextScrollEventCount_ = 0;
			}
			return false;
		}

		function setPercentScroll(percent) {
			// calculates viewer's GUI-dependent pixel-based raw percent
			const viewerPercent = scrollmap.translateL2V(percent);
			const newScrollTop = viewerPercent * maxScrollTop();

			// Even if the scroll position hasn't changed (percent is the same),
			// we still ignore the next scroll event, so that it doesn't create
			// undesired side effects.
			// https://github.com/laurent22/joplin/issues/7617
			ignoreNextScrollEvent();
			
			if (Math.floor(contentElement.scrollTop) !== Math.floor(newScrollTop)) {
				percentScroll_ = percent;
				contentElement.scrollTop = newScrollTop;
			}
		}

		function restorePercentScroll() {
			setPercentScroll(percentScroll_);
		}

		// Note that this function keeps track of what's been added so as not to
		// add the same CSS files multiple times.
		function addPluginAssets(assets) {
			if (!assets) return;

			const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer');

			const processedAssetIds = [];

			for (let i = 0; i < assets.length; i++) {
				const asset = assets[i];
				
				// # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment
				const encodedPath = asset.path
						.replaceAll('#','%23')
						.replaceAll('?','%3F')

				const assetId = asset.name ? asset.name : encodedPath;

				processedAssetIds.push(assetId);

				if (pluginAssetsAdded_[assetId]) continue;

				let element = null;

				// Needed on Windows:
				//   C:/Path/Here
				// is interpreted as a file path, even without a starting file://.
				let src = encodedPath;
				if (src.match(/^[/]/) || src.match(/^[^:/\\]+[:][\\/]/)) {
					src = `joplin-content://note-viewer/${src}`;
				}

				if (asset.mime === 'application/javascript') {
					element = document.createElement('script');
					element.src = src;
					pluginAssetsContainer.appendChild(element);
				} else if (asset.mime === 'text/css') {
					element = document.createElement('link');
					element.rel = 'stylesheet';
					element.href = src;
					pluginAssetsContainer.appendChild(element);
				}

				pluginAssetsAdded_[assetId] = {
					element,
				}
			}

			// Once we have added the relevant assets, we also remove those that
			// are no longer needed. It's necessary in particular for the CSS
			// generated by noteStyle - if we don't remove it, we might end up
			// with two or more stylesheet and that will create conflicts.
			//
			// It was happening for example when automatically switching from
			// light to dark theme, and then back to light theme - in that case
			// the viewer would remain dark because it would use the dark
			// stylesheet that would still be in the DOM.
			for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
				if (!processedAssetIds.includes(assetId)) {
					try {
						if (asset?.element) asset.element.remove();
					} catch (error) {
						// We don't throw an exception but we log it since
						// it shouldn't happen
						console.warn('Tried to remove asset ' + assetId + ' but got an error:', error);
						console.warn('Assets are:', pluginAssetsAdded_);
					}
					pluginAssetsAdded_[assetId] = null;
				}
			}
		}

		ipc.scrollToHash = (event) => {
			let retry = 0;
			const fn = () => {
				if (window.scrollToHashTimeoutID_) {
					clearInterval(window.scrollToHashTimeoutID_);
					window.scrollToHashTimeoutID_ = null;
				}
				if (document.readyState === 'complete' ||
					// If scrollmap is present, Element.scrollIntoView() is also
					// available when document.readyState is interactive.
					document.readyState === 'interactive' && scrollmap.isPresent()) {
					const hash = event.hash.toLowerCase();
					const e = document.getElementById(hash);
					if (e) {
						e.scrollIntoView();
						// It causes a scroll event, whose listener sent a new scroll
						// position to Editor.
					} else {
						console.warn('Cannot find hash', hash);
					}
				} else {
					retry += 1;
					if (retry <= 10) {
						window.scrollToHashTimeoutID_ = setTimeout(fn, 100);
					}
				}
			};
			fn();
		}

		function isVisible() {
			// See the logic of hiding viewer in CoderMirror.tsx
			return window.innerWidth > 1;
		}

		// https://stackoverflow.com/a/1977898/561309
		function isImageReady(img) {
			if (!img.complete) return false;
			if (!img.naturalWidth || !img.naturalHeight) return false;
			return true;
		}

		function allImagesLoaded() {
			if (!isVisible()) return true; // In the case, images would not be loaded.
			for (const image of document.images) {
				if (!isImageReady(image)) return false;
			}
			return true;
		}

		let alreadyAllImagesLoaded = false;

		// During a note is being rendered, its height is varying. To keep scroll
		// consistency, observing the height of the content element and updating its
		// scroll position is required. For the purpose, 'ResizeObserver' is used.
		// ResizeObserver is standard and an element's counterpart to 'window.resize'
		// event. It's overhead is cheaper than observation using an interval timer.
		//
		// To observe the scroll height of the content element, adding, removing and
		// resizing of its children should be observed. So, the combination of
		// ResizeObserver (used for resizing) and MutationObserver (used for ading
		// and removing) is used.
		//
		// References:
		// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
		// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
		//
		// By using them, this observeRendering() function provides a efficient way
		// to observe the changes of the scroll height of the content element
		// using a callback approach.
		function observeRendering(callback, compress = false) {
			let lastScrollHeight = 0;
			let lastClientHeight = 0;
			const fn = (cause) => {
				const sh = contentElement.scrollHeight;
				const ch = contentElement.clientHeight;
				const heightChanged = (sh !== lastScrollHeight || ch !== lastClientHeight);
				if (!compress || heightChanged) {
					lastScrollHeight = sh;
					lastClientHeight = ch;
					callback(cause, sh, heightChanged);
				}
			};
			// 'resized' means DOM Layout change or Window resize event
			let resizeObserver = new ResizeObserver(() => fn('resized'));
			// An HTML document to be rendered is added and removed as a child of
			// the content element for each setHtml() invocation.
			let mutationObserver = new MutationObserver(entries => {
				const e = entries[0];
				e.removedNodes.forEach(n => n instanceof Element && resizeObserver.unobserve(n));
				e.addedNodes.forEach(n => n instanceof Element && resizeObserver.observe(n));
				if (e.removedNodes.length + e.addedNodes.length) fn('dom-changed');
			});
			mutationObserver.observe(contentElement, { childList: true });
			return { mutationObserver, resizeObserver };
		};

		// To suppress too frequent restoring of scroll positions and refreshing of the scroll map
		let restoreAndRefreshTimeoutID_ = null;
		let restoreAndRefreshTimeout_ = Date.now();

		// If 'noteRenderComplete' message is ongoing, resizing should not trigger a 'percentScroll' messsage.
		let noteRenderCompleteMessageIsOngoing_ = false;

		// A callback anonymous function invoked when the scroll height changes.
		const onRendering = observeRendering((cause, height, heightChanged) => {
			if (!alreadyAllImagesLoaded && !scrollmap.isPresent()) {
				const loaded = allImagesLoaded();
				if (loaded) {
					alreadyAllImagesLoaded = true;
					scrollmap.refresh();
					restorePercentScroll();
					noteRenderCompleteMessageIsOngoing_ = true;
					ipcProxySendToHost('noteRenderComplete');
					return;
				}
			}
			if (!heightChanged && cause !== 'dom-changed') return;
			const restoreAndRefresh = () => {
				scrollmap.refresh();
				restorePercentScroll();
				// To ensures Editor's scroll position is synced with Viewer's
				if (!noteRenderCompleteMessageIsOngoing_) ipcProxySendToHost('percentScroll', percentScroll_);
			};
			const now = Date.now();
			if (now < restoreAndRefreshTimeout_) {
				if (restoreAndRefreshTimeoutID_) {
					clearTimeout(restoreAndRefreshTimeoutID_);
					restoreAndRefreshTimeoutID_ = null;
				}
				const msec = Math.min(1000, restoreAndRefreshTimeout_ - now);
				restoreAndRefreshTimeoutID_ = setTimeout(restoreAndRefresh, msec);
			} else {
				restoreAndRefresh();
			}
			restoreAndRefreshTimeout_ = now + 200;
		});

		ipc.focus = (event) => {
			const dummyID = 'joplin-content-focus-dummy';
			if (! document.getElementById(dummyID)) {
				const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#">Note viewer top</a></div>';
				contentElement.insertAdjacentHTML("afterbegin", focusDummy);
			}
			const scrollTop = contentElement.scrollTop;
			document.getElementById(dummyID).focus();
			contentElement.scrollTop = scrollTop;
		}

		const rewriteFileUrls = (accessKey) => {
			if (!accessKey) return;

			// To allow accessing local files from the viewer's non-file URL, file:// URLs are re-written
			// to joplin-content:// URLs:
			const mediaElements = document.querySelectorAll('video[src], audio[src], source[src], img[src]');
			for (const element of mediaElements) {
				if (element.src?.startsWith('file:')) {
					const newUrl = element.src.replace(/^file:\/\//, 'joplin-content://file-media/');
					element.src = `${newUrl}?access-key=${accessKey}`;
				}
			}
		};

		ipc.setHtml = (event) => {
			const html = event.html;

			markJsHackMarkerInserted_ = false;

			updateBodyHeight();

			alreadyAllImagesLoaded = false;

			contentElement.innerHTML = html;

			if (html.includes('file://')) {
				rewriteFileUrls(event.options.mediaAccessKey);
			}

			scrollmap.create(event.options.markupLineCount);
			if (typeof event.options.percent !== 'number') {
				restorePercentScroll(); // First, a quick treatment is applied.
			} else {
				setPercentScroll(event.options.percent);
			}

			addPluginAssets(event.options.pluginAssets);

			if (event.options.downloadResources === 'manual') {
				webviewLib.setupResourceManualDownload();
			}

			document.dispatchEvent(new Event('joplin-noteDidUpdate'));

			if (scrollmap.isPresent()) {
				// Now, ready to receive scrollToHash/setPercentScroll from Editor.
				noteRenderCompleteMessageIsOngoing_ = true;
				ipcProxySendToHost('noteRenderComplete');
			}
		}

		ipc.setPercentScroll = (event) => {
			noteRenderCompleteMessageIsOngoing_ = false;
			setPercentScroll(event.percent);
		}

		// HACK for Mark.js bug - https://github.com/julmot/mark.js/issues/127
		let markJsHackMarkerInserted_ = false;
		function addMarkJsSpaceHack(document) {
			if (markJsHackMarkerInserted_) return;
			
			const prepareElementsForMarkJs = (elements, type) => {
				// const markJsHackMarker_ = '&#8203; &#8203;'
				const markJsHackMarker_ = ' ';
				for (let i = 0; i < elements.length; i++) {
					if (!type) {
						elements[i].insertAdjacentHTML('beforeend', markJsHackMarker_);
					} else if (type === 'insertBefore') {
						elements[i].insertAdjacentHTML('beforeBegin', markJsHackMarker_);
					}
				}
			}

			prepareElementsForMarkJs(contentElement.getElementsByTagName('p'));
			prepareElementsForMarkJs(contentElement.getElementsByTagName('div'));
			prepareElementsForMarkJs(contentElement.getElementsByTagName('br'), 'insertBefore');
			markJsHackMarkerInserted_ = true;
		}

		let mark_ = null;
		let markSelectedElement_ = null;
		function setMarkers(keywords, options = null) {
			if (!options) options = {};

			// TODO: Add support for scriptType on mobile and CLI

			if (!mark_) {
				mark_ = new Mark(document.getElementById('joplin-container-content'), {
					exclude: ['img'],
					acrossElements: true,
				});
			}

			addMarkJsSpaceHack(document);

			mark_.unmark()

			if (markSelectedElement_) markSelectedElement_.classList.remove('mark-selected');

			let selectedElement = null;
			let elementIndex = 0;

			const markKeywordOptions = {};

			if ('separateWordSearch' in options) markKeywordOptions.separateWordSearch = options.separateWordSearch;

			try {
				for (const keyword of keywords) {
					markJsUtils.markKeyword(mark_, keyword, {
						pregQuote: pregQuote,
						replaceRegexDiacritics: replaceRegexDiacritics,
					}, markKeywordOptions);
				}
			} catch (error) {
				if (error.name !== 'SyntaxError') {
					throw error;
				}
				// An error of 'Regular expression too large' might occour in the markJs library
				// when the input is really big, this catch is here to avoid the application crashing
				// https://github.com/laurent22/joplin/issues/7634
				console.error('Error while trying to highlight words from search: ', error);
			}
		}

		let markLoader_ = { state: 'idle', whenDone: null };
		ipc.setMarkers = (event) => {
			const keywords = event.keywords;
			const options = event.options;

			if (!keywords.length && markLoader_.state === 'idle') return;

			if (markLoader_.state === 'idle') {
				markLoader_ = {
					state: 'loading',
					whenDone: {keywords:keywords, options:options},
				};

				const script = document.createElement('script');
				script.onload = function() {
					markLoader_.state = 'ready';
					setMarkers(markLoader_.whenDone.keywords, markLoader_.whenDone.options);
				};

				script.src = '../../vendor/lib/mark.js/dist/mark.min.js';
				document.getElementById('joplin-container-markScriptContainer').appendChild(script);
			} else if (markLoader_.state === 'ready') {
				setMarkers(keywords, options);
			} else if (markLoader_.state === 'loading') {
				markLoader_.whenDone = {keywords:keywords, options:options};
			}
		}

		function maxScrollTop() {
			return Math.max(0, contentElement.scrollHeight - contentElement.clientHeight);
		}

		function maxScrollLeft() {
			return Math.max(0, contentElement.scrollWidth - contentElement.clientWidth);
		}

		// The body element needs to have a fixed height for the content to be scrollable
		function updateBodyHeight() {
			document.getElementById('joplin-container-body').style.height = window.innerHeight + 'px';
			document.getElementById('joplin-container-content').style.height = window.innerHeight + 'px';
		}

		function getPercentFromViewer() {
			const m = maxScrollTop();
			// As of 2021, if zoomFactor != 1, underlying Chrome returns scrollTop with 
			// some numerical error. It can be more than maxScrollTop().
			const viewerPecent = m ? Math.min(1, contentElement.scrollTop / m) : 0;
			// calculates GUI-independent line-based logical percent
			const percent = scrollmap.translateV2L(viewerPecent);
			return percent;
		}

		// If zoom factor is not 1, Electron/Chromium calculates scrollTop incorrectly.
		// This is automatically set.
		let zoomFactorIsNotOne = false;
		// When custom smooth scrolling is ongoing, remainedScrollDx/Dy keep the remaining
		// amount of scrolling.
		let remainedScrollDx = 0, remainedScrollDy = 0, remainedScrollTimerId = null;

		function resetSmoothScroll() { remainedScrollDx = 0; remainedScrollDy = 0; }

		// To avoid Electron/Chromium's scrolling bug when zoom fator is not 1,
		// Custom scrolling is implemented. This is used only when zoom factor is not 1.
		// If smoothly argument is true, smooth scrolling is performed.
		// See https://github.com/laurent22/joplin/pull/5606#issuecomment-964293459
		function customScroll(wheelEvent, smoothly) {
			const linePixels = 100 / 3;
			const pagePixelsX = Math.max(linePixels, contentElement.clientWidth);
			const pagePixelsY = Math.max(linePixels, contentElement.clientHeight);
			let pixelsPerUnitX = 1, pixelsPerUnitY = 1; // for WheelEvent.DOM_DELTA_PIXEL
			if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_LINE) {
				pixelsPerUnitX = pixelsPerUnitY = linePixels;
			} else if (wheelEvent.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
				pixelsPerUnitX = pagePixelsX;
				pixelsPerUnitY = pagePixelsY;
			}
			if (!smoothly) {
				if (wheelEvent.deltaX) {
					const dx = wheelEvent.deltaX * pixelsPerUnitX;
					contentElement.scrollLeft = Math.max(0, Math.min(maxScrollLeft(), contentElement.scrollLeft + dx));
				}
				if (wheelEvent.deltaY) {
					const dy = wheelEvent.deltaY * pixelsPerUnitY;
					contentElement.scrollTop = Math.max(0, Math.min(maxScrollTop(), contentElement.scrollTop + dy));
				}
			} else {
				if (Math.sign(remainedScrollDx) !== Math.sign(wheelEvent.deltaX)) remainedScrollDx = 0;
				if (Math.sign(remainedScrollDy) !== Math.sign(wheelEvent.deltaY)) remainedScrollDy = 0;
				remainedScrollDx += wheelEvent.deltaX * pixelsPerUnitX;
				remainedScrollDy += wheelEvent.deltaY * pixelsPerUnitY;
				const maxDx = Math.max(8.5, Math.min(pagePixelsX, Math.abs(remainedScrollDx)) / 5);
				const maxDy = Math.max(8.5, Math.min(pagePixelsY, Math.abs(remainedScrollDy)) / 5);
				const f = () => {
					if (remainedScrollTimerId) {
						clearTimeout(remainedScrollTimerId);
						remainedScrollTimerId = null;
					}
					if (remainedScrollDx) {
						const dx = Math.max(-maxDx, Math.min(maxDx, remainedScrollDx));
						remainedScrollDx -= dx;
						contentElement.scrollLeft = Math.max(0, Math.min(maxScrollLeft(), contentElement.scrollLeft + dx));
					}
					if (remainedScrollDy) {
						const dy = Math.max(-maxDy, Math.min(maxDy, remainedScrollDy));
						remainedScrollDy -= dy;
						contentElement.scrollTop = Math.max(0, Math.min(maxScrollTop(), contentElement.scrollTop + dy));
					}
					if (remainedScrollDx || remainedScrollDy) remainedScrollTimerId = setTimeout(f, 20);
				};
				f();
			}
		}

		contentElement.addEventListener('wheel', webviewLib.logEnabledEventHandler(e => {
			// When zoomFactor is not 1 (using an HD display is a typical case), 
			// DOM element's scrollTop is incorrectly calculated after wheel scroll events 
			// in the layer of Electron/Chromium, as of 2021-09-23.
			// To avoid this problem, prevent the upstream from calculating scrollTop and
			// calculate by yourself by accumulating wheel events.
			// https://github.com/laurent22/joplin/pull/5496
			// When the Electron/Chromium bug is fixed, remove this listener.
			//
			// 2024-02-01: The bug seems to be fixed, remove the above when we're not in
			//             feature-freeze.

			// If scrollTop ever has a fraction part, zoomFactor is not 1.
			if (zoomFactorIsNotOne || !Number.isInteger(contentElement.scrollTop)) {
				zoomFactorIsNotOne = true;

				// The custom scroll logic breaks horizontal scroll in child DOM nodes
				// (e.g. scrollable code blocks). Disable it:
				if (e.deltaY !== 0) {
					customScroll(e, true);
					e.preventDefault();
				}
			}
		}));

		contentElement.addEventListener('scroll', webviewLib.logEnabledEventHandler(e => {
			lastScrollTop_ = contentElement.scrollTop;
			// If the last scroll event was done by the application, ignoreNextScrollEvent() is called and
			// we can use that to skip the event handling. We skip it because in that case
			// the scroll position has already been updated. Also we add a 200ms interval
			// because otherwise it's most likely a glitch where we called ipc.setPercentScroll
			// but the scroll event listener has not been called.
			if (isNextScrollEventIgnored()) return;
			percentScroll_ = getPercentFromViewer();
			ipcProxySendToHost('percentScroll', percentScroll_);
		}));

		ipc['postMessageService.response'] = function(event) {
			const promise = webviewApiPromises_[event.responseId];
			if (!promise) {
				console.warn('postMessageService.response: could not find callback for message', event);
				return;
			}

			if (event.error) {
				promise.reject(event.error);
			} else {
				promise.resolve(event.response);
			}
		}

		ipc.textSelected = function(event) {
			ipcProxySendToHost('contextMenu', {
				type: 'text',
				textToCopy: event.text,
			});
		}

		ipc.openPdfViewer = function(event) {
			ipcProxySendToHost('openPdfViewer', { resourceId: event.resourceId, mime: 'application/pdf', pageNo: event.pageNo || 1 });
		}

		window.addEventListener('hashchange', webviewLib.logEnabledEventHandler(e => {
			if (!window.location.hash) return;

			// The timeout is necessary to prevent a race condition and give time for the window to scroll
			setTimeout(() => {
				// Reset the window hash to allow clicking on the same anchor link more than once
				window.location.hash = '';
			}, 100);
		}));

		document.addEventListener('contextmenu', webviewLib.logEnabledEventHandler(event => {
			// To handle right clicks on resource icons
			let element = event.target;
			
			// Mermaid svgs are wrapped inside a <pre> with class "mermaid"
			let mermaidElement = element.closest(".mermaid")?.children[0];
			if (mermaidElement) {
				const svgString = new XMLSerializer().serializeToString(mermaidElement);
				if (!!svgString) {
					ipcProxySendToHost('contextMenu', {
						type: 'image',
						textToCopy: svgString,
						mime: 'image/svg+xml',
						filename: mermaidElement.id + '.svg',
					});
				}
				return;
			}

			if (element && !element.getAttribute('data-resource-id')) element = element.parentElement;

			if (element && element.getAttribute('data-resource-id')) {
				ipcProxySendToHost('contextMenu', {
					type: element.getAttribute('src') ? 'image' : 'resource',
					resourceId: element.getAttribute('data-resource-id'),
				});
			} else {
				const selectedText = window.getSelection().toString();

				if (selectedText) {
					const linkToCopy = event.target && event.target.getAttribute('href') ? event.target.getAttribute('href') : null;

					ipcProxySendToHost('contextMenu', {
						type: 'text',
						textToCopy: selectedText,
						linkToCopy: linkToCopy,
					});
				} else if (event.target.getAttribute('href')) {
					ipcProxySendToHost('contextMenu', {
						type: 'link',
						textToCopy: event.target.getAttribute('href'),
					});
				}
			}
		}));

		webviewLib.initialize({
			postMessage: ipcProxySendToHost,
		});

		// Disable drag and drop otherwise it's possible to drop a URL
		// on it and it will open in the view as a website.
		document.addEventListener('drop', webviewLib.logEnabledEventHandler(e => {
			e.preventDefault();
			e.stopPropagation();
		}));
		document.addEventListener('dragover', webviewLib.logEnabledEventHandler(e => {
			e.preventDefault();
			e.stopPropagation();
		}));
		document.addEventListener('dragover', webviewLib.logEnabledEventHandler(e => {
			e.preventDefault();
		}));

		document.addEventListener('click', webviewLib.logEnabledEventHandler(e => {
			// Links should all have custom click handlers. Allowing Electron to load custom links
			// can cause security issues, particularly if these links have the same domain as the
			// top-level page.
			if (e.target.hasAttribute('href')) {
				e.preventDefault();
			}

			document.querySelectorAll('.media-pdf').forEach(element => {
				if(!!element.contentWindow){
					element.contentWindow.postMessage({
						type: 'blur'
					}, '*');
				}
			}
			);
		}));

		let lastClientWidth_ = NaN, lastClientHeight_ = NaN, lastScrollTop_ = NaN;

		window.addEventListener('resize', webviewLib.logEnabledEventHandler(() => {
			updateBodyHeight();
			// When zoomFactor is changed, resize event happens.
			zoomFactorIsNotOne = false;
			resetSmoothScroll();

			// If this event resizes contentElement, ignore the scroll event caused by it.
			const cw = contentElement.clientWidth;
			const ch = contentElement.clientHeight;
			const top = contentElement.scrollTop;
			if (!(cw === lastClientWidth_ && ch === lastClientHeight_)) {
				// Since scroll listeners are invoked before ResizeObserver and
				// resize listeners are invoked before scroll listeners,
				// this code should be here to ignore scroll events.
				if (top !== lastScrollTop_) ignoreNextScrollEvent();
				lastClientWidth_ = cw; lastClientHeight_ = ch; lastScrollTop_ = top;
			}
		}));

		// Prevent middle-click as that would open the URL in an Electron window
		// https://github.com/laurent22/joplin/issues/3287
		window.addEventListener('auxclick', webviewLib.logEnabledEventHandler((event) => {
			event.preventDefault();
		}));

		updateBodyHeight();
	} catch (error) {
		ipcProxySendToHost('error:' + JSON.stringify(webviewLib.cloneError(error)));
		throw error;
	}
	</script>
</body>
</html>
